iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Rust

大家一起跟Rust當好朋友吧!系列 第 9

Day 9: 錯誤處理 (Error Handling):從 `panic!` 到 `Result`

  • 分享至 

  • xImage
  •  

嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第九天!

昨天我們學習了 Rust 的三大集合型別,掌握了如何處理動態資料結構。今天我們要來探討一個在實際開發中極其重要的主題:錯誤處理

如果說集合型別讓我們能組織和管理資料,那麼良好的錯誤處理就是讓程式在面對意外情況時依然能優雅運行的關鍵。在其他語言中,我們可能習慣了例外處理機制(try-catch),但 Rust 選擇了一條不同的路:它將錯誤作為型別系統的一部分,強迫我們在編譯時就思考並處理可能出現的錯誤。

老實說,剛開始接觸 Rust 的錯誤處理時,我覺得它有那麼一點點「太嚴格」了。為什麼連開啟一個檔案都要我處理錯誤!但隨著深入了解,我發現這種設計理念讓程式變得更加可靠和可預測。那麼,就讓我們今天一起來探索這個讓 Rust 程式如此強壯的錯誤處理體系!

Rust 的錯誤處理哲學

在深入語法之前,讓我們先理解 Rust 對錯誤的分類:

兩種錯誤類型

1. 不可恢復的錯誤 (Unrecoverable Errors)

  • 程式遇到嚴重問題,無法繼續執行
  • 使用 panic! 巨集處理
  • 例如:陣列越界、除以零、程式邏輯錯誤

2. 可恢復的錯誤 (Recoverable Errors)

  • 程式可以處理並繼續執行的錯誤
  • 使用 Result<T, E> 型別處理
  • 例如:檔案不存在、網路連線失敗、使用者輸入無效

這種分類讓我們在設計程式時就要思考:這個錯誤是否應該讓程式崩潰,還是可以優雅地處理並繼續執行?

panic!:當一切都無法挽回時

基本 panic! 使用

當程式遇到無法恢復的錯誤時,可以使用 panic! 巨集:

fn main() {
    println!("程式開始執行");
    
    // 手動觸發 panic
    panic!("糟糕!出現了嚴重錯誤!");
    
    println!("這行永遠不會執行");
}

執行這個程式會看到:

thread 'main' panicked at '糟糕!出現了嚴重錯誤!', src/main.rs:5:5

自動觸發的 panic!

許多操作在失敗時會自動觸發 panic:

fn main() {
    let v = vec![1, 2, 3];
    
    // 這會引發 panic,因為索引越界
    let element = v[99];
    
    println!("元素:{}", element);
}

panic! 的執行機制

當 panic 發生時,Rust 會:

  1. 展開堆疊:從 panic 點開始,逐層清理堆疊上的變數
  2. 釋放記憶體:確保沒有記憶體洩漏
  3. 終止程式:最終終止整個程式

你也可以設定程式在 panic 時直接終止而不展開:

# Cargo.toml
[profile.release]
panic = 'abort'

自訂 panic 處理器

use std::panic;

fn main() {
    // 設定自訂的 panic 處理器
    panic::set_hook(Box::new(|panic_info| {
        println!("🚨 程式發生 panic!");
        if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
            println!("錯誤訊息:{}", s);
        }
        if let Some(location) = panic_info.location() {
            println!("位置:{}:{}:{}", 
                     location.file(), 
                     location.line(), 
                     location.column());
        }
    }));
    
    println!("準備觸發 panic...");
    panic!("測試 panic 處理器");
}

Result<T, E>:優雅的錯誤處理

大部分情況下,我們希望程式能優雅地處理錯誤而不是直接崩潰。這就是 Result<T, E> 的用武之地。

Result 的基本結構

Result 是一個枚舉,定義如下:

enum Result<T, E> {
    Ok(T),      // 成功,包含類型 T 的值
    Err(E),     // 失敗,包含類型 E 的錯誤
}

使用 match 處理 Result

use std::fs::File;

fn main() {
    let filename = "hello.txt";
    
    let file_result = File::open(filename);
    
    match file_result {
        Ok(file) => {
            println!("檔案開啟成功!");
            // 在這裡可以使用 file
        },
        Err(error) => {
            println!("開啟檔案失敗:{}", error);
        }
    }
}

不同型別的錯誤處理

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let filename = "hello.txt";
    
    let file = File::open(filename);
    
    let file = match file {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => {
                println!("檔案不存在,嘗試建立新檔案");
                match File::create(filename) {
                    Ok(new_file) => {
                        println!("檔案建立成功!");
                        new_file
                    },
                    Err(create_error) => {
                        panic!("無法建立檔案:{:?}", create_error);
                    }
                }
            },
            ErrorKind::PermissionDenied => {
                panic!("權限不足:{:?}", error);
            },
            other_error => {
                panic!("其他錯誤:{:?}", other_error);
            }
        }
    };
    
    println!("檔案操作完成");
}

unwrapexpect:快速處理的捷徑

當你確定 Result 一定是 Ok 時,可以使用 unwrapexpect

use std::fs::File;

fn main() {
    // unwrap:如果是 Err 就 panic
    let file1 = File::open("definitely_exists.txt").unwrap();
    
    // expect:如果是 Err 就 panic,並顯示自訂訊息
    let file2 = File::open("another_file.txt")
        .expect("無法開啟檔案:another_file.txt");
    
    println!("檔案開啟成功");
}

⚠️ 重要提醒

unwrapexpect 應該謹慎使用,主要適用於:

  • 開發階段
  • 你確定不會失敗的情況
  • 程式邏輯錯誤應該導致崩潰的情況

在正式產品中,優先考慮使用 matchif let? 運算子來優雅地處理錯誤!

錯誤傳播:? 運算子的魔法

在實際開發中,我們經常需要將錯誤向上傳播。? 運算子讓這個過程變得非常簡潔:

傳統的錯誤傳播

use std::fs::File;
use std::io::{self, Read};

fn read_file_contents_verbose(filename: &str) -> Result<String, io::Error> {
    let file_result = File::open(filename);
    
    let mut file = match file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    
    let mut contents = String::new();
    
    let read_result = file.read_to_string(&mut contents);
    
    match read_result {
        Ok(_) => Ok(contents),
        Err(e) => Err(e),
    }
}

使用 ? 運算子簡化

use std::fs::File;
use std::io::{self, Read};

fn read_file_contents(filename: &str) -> Result<String, io::Error> {
    let mut file = File::open(filename)?;  // 如果失敗就提早返回錯誤
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;   // 如果失敗就提早返回錯誤
    Ok(contents)                           // 成功就返回內容
}

fn main() {
    match read_file_contents("test.txt") {
        Ok(contents) => println!("檔案內容:\n{}", contents),
        Err(error) => println!("讀取失敗:{}", error),
    }
}

更進一步的簡化

use std::fs;
use std::io;

fn read_file_contents_shortest(filename: &str) -> Result<String, io::Error> {
    fs::read_to_string(filename)  // 標準函式庫提供的便利函式
}

// 或者使用 ? 運算子的鏈式調用
fn read_and_process(filename: &str) -> Result<usize, io::Error> {
    Ok(fs::read_to_string(filename)?.len())
}

main 函式中的錯誤處理

main 函式也可以回傳 Result

use std::error::Error;
use std::fs;

fn main() -> Result<(), Box<dyn Error>> {
    let content = fs::read_to_string("config.txt")?;
    println!("設定檔內容:{}", content);
    
    let number: i32 = content.trim().parse()?;
    println!("解析的數字:{}", number);
    
    Ok(())
}

自訂錯誤型別

當你的應用變得複雜時,可能需要自訂錯誤型別:## 實用的錯誤處理技巧

1. 使用 anyhow 簡化錯誤處理

在實際專案中,anyhow 是一個非常受歡迎的錯誤處理函式庫:

# Cargo.toml
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
use std::fs;

fn process_file(filename: &str) -> Result<usize> {
    let content = fs::read_to_string(filename)
        .with_context(|| format!("無法讀取檔案 '{}'", filename))?;
    
    let number: i32 = content.trim().parse()
        .with_context(|| format!("無法解析檔案內容為數字: '{}'", content.trim()))?;
    
    if number < 0 {
        anyhow::bail!("數字不能是負數: {}", number);
    }
    
    Ok(number as usize)
}

fn main() -> Result<()> {
    let result = process_file("data.txt")?;
    println!("處理結果: {}", result);
    Ok(())
}

2. 使用 thiserror 定義結構化錯誤

對於函式庫開發,thiserror 讓定義錯誤型別變得非常簡單:

[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("資料不存在")]
    NotFound,
    
    #[error("無效的 ID: {id}")]
    InvalidId { id: u32 },
    
    #[error("IO 錯誤")]
    Io(#[from] std::io::Error),
    
    #[error("JSON 解析錯誤")]
    Json(#[from] serde_json::Error),
    
    #[error("網路錯誤: {0}")]
    Network(String),
}

3. 錯誤處理的最佳實務

use std::fs::File;
use std::io::{self, Read};

// ✅ 好的做法:清楚的錯誤類型和處理
fn read_config() -> Result<String, io::Error> {
    let mut file = File::open("config.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

// ✅ 好的做法:合適的錯誤訊息
fn parse_config(content: &str) -> Result<i32, String> {
    content.trim().parse()
        .map_err(|_| format!("無法解析設定值 '{}' 為數字", content.trim()))
}

// ✅ 好的做法:組合多個可能失敗的操作
fn load_and_parse_config() -> Result<i32, Box<dyn std::error::Error>> {
    let content = read_config()?;
    let value = parse_config(&content)?;
    Ok(value)
}

// ❌ 不好的做法:過度使用 unwrap
fn bad_example() -> i32 {
    let content = std::fs::read_to_string("config.txt").unwrap();
    content.trim().parse().unwrap()
}

Option vs Result:選擇合適的工具

理解何時使用 Option<T> 和何時使用 Result<T, E> 是很重要的:

use std::collections::HashMap;

struct UserDatabase {
    users: HashMap<u32, String>,
}

impl UserDatabase {
    fn new() -> Self {
        let mut users = HashMap::new();
        users.insert(1, "Alice".to_string());
        users.insert(2, "Bob".to_string());
        users.insert(3, "Charlie".to_string());
        Self { users }
    }
    
    // 使用 Option:值可能存在或不存在,但沒有錯誤
    fn get_user(&self, id: u32) -> Option<&String> {
        self.users.get(&id)
    }
    
    // 使用 Result:操作可能失敗,需要錯誤資訊
    fn add_user(&mut self, id: u32, name: String) -> Result<(), String> {
        if self.users.contains_key(&id) {
            Err(format!("使用者 ID {} 已存在", id))
        } else if name.is_empty() {
            Err("使用者名稱不能為空".to_string())
        } else {
            self.users.insert(id, name);
            Ok(())
        }
    }
    
    // 結合使用:查找並更新
    fn update_user(&mut self, id: u32, new_name: String) -> Result<String, String> {
        if new_name.is_empty() {
            return Err("新名稱不能為空".to_string());
        }
        
        match self.users.get_mut(&id) {
            Some(name) => {
                let old_name = name.clone();
                *name = new_name;
                Ok(old_name)
            },
            None => Err(format!("找不到 ID 為 {} 的使用者", id))
        }
    }
}

fn main() {
    let mut db = UserDatabase::new();
    
    // Option 的使用
    match db.get_user(1) {
        Some(name) => println!("找到使用者:{}", name),
        None => println!("使用者不存在"),
    }
    
    // Result 的使用
    match db.add_user(4, "Diana".to_string()) {
        Ok(()) => println!("使用者新增成功"),
        Err(e) => println!("新增失敗:{}", e),
    }
    
    // 錯誤案例
    match db.add_user(1, "Eve".to_string()) {
        Ok(()) => println!("使用者新增成功"),
        Err(e) => println!("新增失敗:{}", e),
    }
}

錯誤處理的效能考量

use std::time::Instant;

// 模擬可能失敗的操作
fn risky_operation(should_fail: bool) -> Result<i32, String> {
    if should_fail {
        Err("操作失敗".to_string())
    } else {
        Ok(42)
    }
}

fn performance_comparison() {
    let iterations = 1_000_000;
    
    // 測試成功路徑的效能
    let start = Instant::now();
    for _ in 0..iterations {
        let _ = risky_operation(false);
    }
    let success_duration = start.elapsed();
    
    // 測試失敗路徑的效能
    let start = Instant::now();
    for _ in 0..iterations {
        let _ = risky_operation(true);
    }
    let error_duration = start.elapsed();
    
    println!("成功路徑耗時:{:?}", success_duration);
    println!("錯誤路徑耗時:{:?}", error_duration);
    
    // Result 在成功路徑上幾乎沒有開銷
    // 錯誤路徑稍慢,但仍然很快
}

常見錯誤處理模式

1. 提早返回模式

fn process_data(input: &str) -> Result<i32, String> {
    // 驗證輸入
    if input.is_empty() {
        return Err("輸入不能為空".to_string());
    }
    
    // 解析數字
    let number: i32 = input.parse()
        .map_err(|_| "無法解析為數字".to_string())?;
    
    // 驗證範圍
    if number < 0 {
        return Err("數字不能為負數".to_string());
    }
    
    if number > 100 {
        return Err("數字不能超過 100".to_string());
    }
    
    // 處理邏輯
    Ok(number * 2)
}

2. 錯誤累積模式

#[derive(Debug)]
struct ValidationErrors {
    errors: Vec<String>,
}

impl ValidationErrors {
    fn new() -> Self {
        Self { errors: Vec::new() }
    }
    
    fn add_error(&mut self, error: String) {
        self.errors.push(error);
    }
    
    fn has_errors(&self) -> bool {
        !self.errors.is_empty()
    }
    
    fn into_result<T>(self, value: T) -> Result<T, Self> {
        if self.has_errors() {
            Err(self)
        } else {
            Ok(value)
        }
    }
}

fn validate_user_input(
    name: &str, 
    email: &str, 
    age: i32
) -> Result<(String, String, i32), ValidationErrors> {
    let mut errors = ValidationErrors::new();
    
    // 累積所有錯誤,而不是遇到第一個就停止
    if name.is_empty() {
        errors.add_error("姓名不能為空".to_string());
    }
    
    if !email.contains('@') {
        errors.add_error("無效的電子郵件格式".to_string());
    }
    
    if age < 0 || age > 150 {
        errors.add_error("年齡必須在 0-150 之間".to_string());
    }
    
    errors.into_result((name.to_string(), email.to_string(), age))
}

3. 重試模式

use std::thread;
use std::time::Duration;

fn retry_operation<F, T, E>(
    mut operation: F,
    max_retries: usize,
    delay: Duration,
) -> Result<T, E>
where
    F: FnMut() -> Result<T, E>,
    E: std::fmt::Debug,
{
    let mut attempts = 0;
    
    loop {
        match operation() {
            Ok(result) => return Ok(result),
            Err(error) => {
                attempts += 1;
                if attempts >= max_retries {
                    eprintln!("操作失敗,已重試 {} 次", attempts);
                    return Err(error);
                }
                
                eprintln!("操作失敗(第 {} 次),{:?} 後重試...", attempts, delay);
                thread::sleep(delay);
            }
        }
    }
}

// 使用重試模式
fn unreliable_network_request() -> Result<String, &'static str> {
    use rand::Rng;
    let mut rng = rand::thread_rng();
    
    if rng.gen_bool(0.7) {  // 70% 機率失敗
        Err("網路連線失敗")
    } else {
        Ok("請求成功".to_string())
    }
}

fn main() {
    let result = retry_operation(
        unreliable_network_request,
        3,  // 最多重試 3 次
        Duration::from_millis(1000),  // 每次重試間隔 1 秒
    );
    
    match result {
        Ok(data) => println!("最終成功:{}", data),
        Err(e) => println!("最終失敗:{}", e),
    }
}

💡 重試模式小提示

這個 retry_operation 函式是個超實用的設計模式!它可以自動重試任何可能失敗的操作,像是網路請求、資料庫連線等。

雖然他看起來有那麼一點點的小複雜,但其實不難理解,他裡面用到的許多泛型的概念,我們明天就會講到啦~

主要核心概念

  • 傳入一個可能失敗的函式(必須回傳 Result
  • 設定最大重試次數和重試間隔
  • 失敗時自動等待並重試,成功時立即回傳結果

這種模式讓我們的程式碼更加健壯,能優雅地處理那些「偶爾會失敗」的操作。特別適合處理不穩定的外部服務!

錯誤處理的設計準則

1. 明確的錯誤類型

// ✅ 好:明確的錯誤類型
#[derive(Debug)]
enum DatabaseError {
    ConnectionFailed,
    QueryFailed(String),
    RecordNotFound,
    ValidationError(String),
}

// ❌ 不好:模糊的錯誤類型
fn bad_function() -> Result<String, String> {
    // 錯誤資訊不夠結構化
    Err("something went wrong".to_string())
}

2. 適當的錯誤粒度

// ✅ 好:適當的錯誤粒度
fn parse_config_file(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)
        .map_err(ConfigError::FileNotFound)?;
    
    let config: Config = serde_json::from_str(&content)
        .map_err(ConfigError::InvalidFormat)?;
    
    validate_config(&config)
        .map_err(ConfigError::ValidationFailed)?;
    
    Ok(config)
}

// ❌ 不好:錯誤粒度過細,難以使用
fn overly_granular() -> Result<String, VerySpecificError> {
    // 每個小操作都有特定的錯誤類型,反而難以處理
    Ok("data".to_string())
}

3. 豐富的錯誤上下文

use std::fs;
use std::path::Path;

fn read_user_config(user_id: u32) -> Result<String, String> {
    let config_path = format!("users/{}/config.json", user_id);
    let path = Path::new(&config_path);
    
    fs::read_to_string(path)
        .map_err(|e| format!(
            "無法讀取使用者 {} 的設定檔 '{}': {}",
            user_id,
            config_path,
            e
        ))
}

今天的收穫

今天我們深入探討了 Rust 的錯誤處理體系:

核心概念

  • 兩種錯誤分類:不可恢復的 panic! vs 可恢復的 Result<T, E>
  • Result<T, E> 是 Rust 錯誤處理的核心
  • ? 運算子:簡化錯誤傳播的語法糖
  • Option<T> vs Result<T, E>:選擇合適的工具

實用技巧

  • 自訂錯誤型別:結構化的錯誤資訊
  • 錯誤轉換:使用 From trait 自動轉換
  • 組合子方法mapand_thenor_else
  • 錯誤處理策略:忽略、預設值、轉換、記錄

最佳實務

  • 明確的錯誤類型設計
  • 適當的錯誤粒度
  • 豐富的錯誤上下文
  • 考慮錯誤的可恢復性

生態系工具

  • anyhow:簡化應用程式錯誤處理
  • thiserror:簡化函式庫錯誤定義
  • 重試、累積、鏈式等錯誤處理模式

為什麼這樣設計?

  • 編譯時安全:強迫處理所有可能的錯誤情況
  • 無隱藏成本:錯誤處理的成本是明確的
  • 組合性:錯誤可以輕鬆地組合和轉換
  • 可讀性:錯誤路徑在型別簽名中明確顯示

Rust 的錯誤處理一開始可能會感覺有一點點小「囉嗦」,但它強迫我們思考所有可能的失敗情況,最終讓程式變得更加強壯和可靠。這種「在編譯時解決問題,而不是在執行時崩潰」的理念是 Rust 安全性保證的重要組成部分。

今天的小挑戰

建立一個簡單的學生成績管理系統,重點練習錯誤處理:

功能需求

  1. 成績錄入:驗證學生 ID、科目名稱、分數範圍
  2. 檔案操作:讀取/寫入成績到 JSON 檔案
  3. 成績查詢:按學生 ID 或科目查詢
  4. 統計功能:計算平均分、最高分、最低分
  5. 批次導入:從 CSV 檔案批次導入成績

錯誤處理要求

  • 定義結構化的錯誤型別
  • 使用 ? 運算子進行錯誤傳播
  • 實現適當的錯誤轉換
  • 提供有意義的錯誤訊息
  • 處理檔案 I/O 錯誤
  • 驗證使用者輸入

技術提示

#[derive(Debug)]
enum GradeSystemError {
    InvalidStudentId(String),
    InvalidSubject(String),
    InvalidScore(f64),
    FileError(std::io::Error),
    ParseError(String),
    NotFound(String),
}

#[derive(Debug, Serialize, Deserialize)]
struct Grade {
    student_id: String,
    subject: String,
    score: f64,
    date: String,
}

struct GradeManager {
    grades: Vec<Grade>,
    file_path: String,
}

impl GradeManager {
    fn new(file_path: String) -> Result<Self, GradeSystemError> {
        // 你來實現!
    }
    
    fn add_grade(&mut self, grade: Grade) -> Result<(), GradeSystemError> {
        // 驗證並新增成績
    }
    
    fn save_to_file(&self) -> Result<(), GradeSystemError> {
        // 保存到檔案
    }
    
    fn load_from_file(&mut self) -> Result<(), GradeSystemError> {
        // 從檔案載入
    }
    
    // 其他方法...
}

這個挑戰將讓你綜合運用今天學到的錯誤處理技巧,包括自訂錯誤型別、錯誤傳播、檔案 I/O 錯誤處理等。記住,重點不是完美的實現,而是理解如何設計清晰、可用的錯誤處理體系。

明天我們將學習 泛型 (Generics),探討如何寫出更彈性、更抽象的程式碼。泛型將讓我們能夠避免重複程式碼,同時保持 Rust 的型別安全性!

如果在實作過程中遇到任何問題,歡迎在留言區討論。錯誤處理是建立可靠軟體的基石,值得我們花時間深入理解和練習!

我們明天見!


上一篇
Day 8: 集合型別:Vectors, Strings, Hash Maps - 理解動態的資料結構
下一篇
Day 10: 泛型 (Generics):寫出彈性又抽象的程式碼
系列文
大家一起跟Rust當好朋友吧!19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言